' : '');
opt.onclick = () => {
// ✅ FIX: se o toque foi um arrasto (scroll), ignora a "seleção" —
// deixa o navegador rolar a lista normalmente.
if (_cstTouchMoveu) { _cstTouchMoveu = false; return; }
document.getElementById('servico').value = i;
fecharSvcDropdown();
_servicoEscolhido = true;
_atualizarBloqueioCalendario();
renderGridServicos();
onServicoChange();
};
dropdown.appendChild(opt);
});
});
}
function _pedirEscolhaBarbeiro(){
const grid = document.getElementById('grid-barbeiros');
if (!grid) return;
grid.dataset.aberto = 'true';
renderGridBarbeiros();
grid.scrollIntoView({ behavior:'smooth', block:'center' });
grid.style.transition = 'box-shadow 0.2s';
grid.style.boxShadow = '0 0 0 2px var(--accent)';
setTimeout(() => { grid.style.boxShadow = ''; }, 1600);
}
function toggleSvcDropdown(){
if (!_barbeiroEscolhido) { _pedirEscolhaBarbeiro(); return; }
const trigger=document.getElementById('svc-trigger'), dropdown=document.getElementById('svc-dropdown');
const aberto=dropdown.classList.contains('open');
if(aberto){fecharSvcDropdown();return;}
// ✅ FIX: usa position:fixed com coords do trigger (getBoundingClientRect)
// em vez de position:absolute — assim o dropdown fica sempre por cima do
// card-data, independente do stacking context criado pelo backdrop-filter
// dos cards (mesmo fix já usado no dropdown de serviço do admin/na-svc-dropdown).
// ✅ FIX 2: backdrop-filter no ancestral (.card) cria um novo containing
// block também para position:fixed — não só absolute. Isso fazia as
// coordenadas (calculadas relativas à viewport) serem aplicadas relativas
// ao card, deslocando o dropdown. Solução: mover o dropdown pro
// enquanto está aberto, escapando de qualquer ancestral com backdrop-filter.
const rect = trigger.getBoundingClientRect();
document.body.appendChild(dropdown);
dropdown.style.position = 'fixed';
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
dropdown.style.right = 'auto';
trigger.classList.add('open');dropdown.classList.add('open');
document.getElementById('card-servico')?.classList.add('svc-dropdown-aberto');
}
function fecharSvcDropdown(){
document.getElementById('svc-trigger').classList.remove('open');
const dropdown = document.getElementById('svc-dropdown');
dropdown.classList.remove('open');
const wrap = document.getElementById('svc-select-wrap');
if (wrap && dropdown.parentElement !== wrap) wrap.appendChild(dropdown);
document.getElementById('card-servico')?.classList.remove('svc-dropdown-aberto');
}
document.addEventListener('click',e=>{
const wrap=document.getElementById('svc-select-wrap');
const dropdown=document.getElementById('svc-dropdown');
const dentroWrap = wrap && wrap.contains(e.target);
const dentroDropdown = dropdown && dropdown.contains(e.target);
if(!dentroWrap && !dentroDropdown) fecharSvcDropdown();
});
// ✅ FIX: fecha o dropdown ao rolar a página, pra ele não ficar "grudado"
// no lugar errado (já que agora é position:fixed, não acompanha o scroll).
// IMPORTANTE: scroll não faz bubble, mas passa pela fase de CAPTURA em
// window mesmo quando o scroll acontece dentro de um elemento filho (ex: a
// própria lista de serviços). Sem o filtro abaixo, rolar a lista fechava o
// dropdown na hora — por isso ignoramos eventos cujo alvo é o próprio dropdown.
window.addEventListener('scroll', (e)=>{
const dropdown = document.getElementById('svc-dropdown');
if (dropdown && (e.target === dropdown || dropdown.contains(e.target))) return;
fecharSvcDropdown();
}, true);
window.addEventListener('resize', ()=>{ fecharSvcDropdown(); });
// [SUBSTITUÍDO] api.run → gsr.call
const _cacheDatasBarb = {}; // { email: { ts, lista } }
const _TTL_DATAS_BARB = 90 * 1000; // ✅ FIX: reduzido de 3min para 90s
function recarregarDatasParaBarbeiro(emailBarbeiro) {
const agora = Date.now();
// FIX: chave do cache inclui a duração do serviço selecionado
// Sem isso, trocar de serviço mantinha datas erradas no cache
const svc = servicos[document.getElementById('servico').value];
const duracaoSvc = svc ? Number(svc.tempo) : 0;
const chaveCache = emailBarbeiro + '|dur' + duracaoSvc;
const hit = _cacheDatasBarb[chaveCache];
// ✅ Cache válido → aplica direto, sem loading nem requisição
if (hit && (agora - hit.ts) < _TTL_DATAS_BARB) {
carregandoDatas = false;
const lista = hit.lista;
if (lista.length && typeof lista[0] === 'object') {
datasDisponiveis = lista.map(r => r.data);
lista.forEach(r => { vagasPorData[r.data] = r.vagas; });
} else {
datasDisponiveis = lista;
}
renderCalendar();
return;
}
// 🔄 Cache expirado ou inexistente → busca no servidor com loading
carregandoDatas = true;
datasDisponiveis = [];
vagasPorData = {};
document.getElementById('cal-loading-overlay').classList.add('visible');
renderCalendar();
// FIX: passa a duração do serviço para a API calcular vagas corretas
gsr.call('getDatasDisponiveisBarbeiro', [emailBarbeiro, duracaoSvc || 0])
.then(resultado => {
carregandoDatas = false;
const lista = resultado || [];
// Salva no cache com chave que inclui duração do serviço
_cacheDatasBarb[chaveCache] = { ts: agora, lista };
// Mantém compatibilidade: persiste no localStorage só para o barbeiro padrão
const b0 = barbeiros[0];
const chavePadrao = b0 ? ((b0.email && b0.email.trim()) ? b0.email.trim() : b0.nome) : '';
if (emailBarbeiro === chavePadrao && lista.length) {
_cacheSalvar(CACHE.DATAS, lista);
}
if (lista.length && typeof lista[0] === 'object') {
datasDisponiveis = lista.map(r => r.data);
lista.forEach(r => { vagasPorData[r.data] = r.vagas; });
} else {
datasDisponiveis = lista;
}
document.getElementById('cal-loading-overlay').classList.remove('visible');
renderCalendar();
})
.catch(() => {
carregandoDatas = false;
document.getElementById('cal-loading-overlay').classList.remove('visible');
renderCalendar();
});
}
function onBarbeiroChange(){
renderGridBarbeiros(); atualizarResumo();
dataSelecionada=''; horarioSelecionado='';
// Reseta escolha de serviço ao trocar de barbeiro
_servicoEscolhido = false;
_atualizarBloqueioCalendario();
document.getElementById('card-horario').style.display='none';
document.getElementById('slotsWrap').innerHTML='';
document.getElementById('s3').className='step'; document.getElementById('line2').className='step-line';
const b=barbeiros[document.getElementById('barbeiro').value];
if(b){const chave=(b.email&&b.email.trim())?b.email.trim():b.nome;recarregarServicosParaBarbeiro(chave);recarregarDatasParaBarbeiro(chave);}
}
function onServicoChange() {
renderGridServicos();
atualizarResumo();
// FIX: quando o serviço muda, as datas disponíveis podem mudar
// (ex: serviço longo pode não caber em dias com poucos slots)
// Recarrega o calendário com a duração do novo serviço
const b = barbeiros[document.getElementById('barbeiro').value];
if (b) {
const chave = (b.email && b.email.trim()) ? b.email.trim() : b.nome;
// Invalida cache de datas (que agora inclui a duração do serviço na chave)
Object.keys(_cacheDatasBarb).forEach(k => {
if (k.startsWith(chave + '|dur')) delete _cacheDatasBarb[k];
});
recarregarDatasParaBarbeiro(chave);
}
if (!dataSelecionada) return;
// FIX: delega para carregarSlots que agora usa chave com duração do serviço.
// Se o cache para essa duração específica não existir, busca do servidor automaticamente.
carregarSlots(dataSelecionada);
}
function mudarMes(dir){
viewMonth+=dir;
if(viewMonth>11){viewMonth=0;viewYear++;}
if(viewMonth<0){viewMonth=11;viewYear--;}
renderCalendar();
}
function renderCalendar(){
document.getElementById('mesAno').textContent=`${MESES[viewMonth]} ${viewYear}`;
const btnPrev=document.getElementById('prevMonth'), btnNext=document.getElementById('nextMonth');
const hojeAno=hoje.getFullYear(), hojeMes=hoje.getMonth();
btnPrev.disabled=(viewYear{const h=document.createElement('div');h.className='cal-header';h.textContent=d;g.appendChild(h);});
const primeiroDiaSemana=new Date(viewYear,viewMonth,1).getDay();
const totalDias=new Date(viewYear,viewMonth+1,0).getDate();
const hoje0=new Date(hoje.getFullYear(),hoje.getMonth(),hoje.getDate());
for(let d=1;d<=totalDias;d++){
const dtAtual=new Date(viewYear,viewMonth,d);
const ds=`${String(d).padStart(2,'0')}/${String(viewMonth+1).padStart(2,'0')}/${viewYear}`;
const cell=document.createElement('div'); cell.className='cal-day';
if(d===1&&primeiroDiaSemana>0)cell.style.gridColumn=(primeiroDiaSemana+1).toString();
const inner=document.createElement('div'); inner.className='cal-day-inner'; inner.textContent=d;
const isToday=dtAtual.getTime()===hoje0.getTime();
const isPast=dtAtualselecionarData(ds);
if(vagasPorData[ds]){
const vagas=vagasPorData[ds];
const v=document.createElement('span');v.className='cal-vagas';v.textContent=vagas+'v';inner.appendChild(v);
const maxV=Math.max(...Object.values(vagasPorData),1);
const pct=Math.round((vagas/maxV)*100);
const cor=vagas>=maxV*0.6?'#34d399':vagas>=maxV*0.3?'#fbbf24':'#f87171';
const wrap=document.createElement('div');wrap.className='cal-bar-wrap';
const fill=document.createElement('div');fill.className='cal-bar-fill';fill.style.width=pct+'%';fill.style.background=cor;
wrap.appendChild(fill);inner.appendChild(wrap);
}
}
else if(isAvailable){
cell.classList.add('available');
if(isToday)cell.classList.add('today');
cell.onclick=()=>selecionarData(ds);
if(vagasPorData[ds]){
const vagas=vagasPorData[ds];
const v=document.createElement('span');v.className='cal-vagas';v.textContent=vagas+'v';inner.appendChild(v);
const maxV=Math.max(...Object.values(vagasPorData),1);
const pct=Math.round((vagas/maxV)*100);
const cor=vagas>=maxV*0.6?'#34d399':vagas>=maxV*0.3?'#fbbf24':'#f87171';
const wrap=document.createElement('div');wrap.className='cal-bar-wrap';
const fill=document.createElement('div');fill.className='cal-bar-fill';fill.style.width=pct+'%';fill.style.background=cor;
wrap.appendChild(fill);inner.appendChild(wrap);
}
}
else if(isToday){cell.classList.add('today');}
else{cell.classList.add('unavailable');}
cell.appendChild(inner); g.appendChild(cell);
}
}
function selecionarData(ds){
if (!_barbeiroEscolhido) { _pedirEscolhaBarbeiro(); return; }
if (!_servicoEscolhido) {
// Chama atenção para o serviço com animação
const wrap = document.getElementById('svc-select-wrap');
if (wrap) {
wrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
wrap.style.transition = 'box-shadow 0.2s';
wrap.style.boxShadow = '0 0 0 2px var(--danger), 0 0 18px rgba(248,113,113,0.35)';
setTimeout(() => { wrap.style.boxShadow = ''; }, 1800);
}
// Abre o dropdown para facilitar
toggleSvcDropdown();
return;
}
dataSelecionada=ds; horarioSelecionado=''; atualizarResumo(); renderCalendar(); carregarSlots(ds);
}
// [SUBSTITUÍDO] api.run → gsr.call (duas chamadas unificadas)
const _cacheSlotsMap = {}; // { "ds|email": { ts, horarios } }
const _TTL_SLOTS = 60 * 1000; // 60 segundos
function carregarSlots(ds) {
const card = document.getElementById('card-horario');
const wrap = document.getElementById('slotsWrap');
card.style.display = '';
const b = barbeiros[document.getElementById('barbeiro').value];
const emailBarbeiro = b ? (b.email || b.nome) : '';
// FIX: inclui duração do serviço na chave do cache — serviços diferentes podem ter
// conjuntos de slots livres diferentes (ex: slot livre para 10min mas ocupado para 40min).
const svcAtual = servicos[document.getElementById('servico').value];
const durSvcAtual = svcAtual ? Number(svcAtual.tempo) : 0;
const chaveSlot = ds + '|' + emailBarbeiro + '|dur' + durSvcAtual;
const agora = Date.now();
const hit = _cacheSlotsMap[chaveSlot];
// ✅ Cache válido → renderiza direto, sem requisição nem spinner
if (hit && (agora - hit.ts) < _TTL_SLOTS) {
montarSlots(hit.horarios);
return;
}
// 🔄 Cache expirado ou inexistente → busca no servidor
wrap.innerHTML = `
Buscando horários...
`;
// FIX: passa duração do serviço para a API filtrar overlap corretamente.
// Sem isso, a API usa durServico=bMin (ex: 40min) para qualquer serviço.
// Sempre passa a duração real (nunca 0 ou string vazia — API interpreta 0 como bMin).
const _durParaApi = durSvcAtual > 0 ? durSvcAtual : (_intervaloConfig || 30);
const p = emailBarbeiro
? gsr.call('getHorariosPorBarbeiro', [ds, emailBarbeiro, _durParaApi])
: gsr.call('getHorarios', [ds, _durParaApi]);
p.then(h => {
_cacheSlotsMap[chaveSlot] = { ts: agora, horarios: h || [] };
montarSlots(h);
})
.catch(() => {
wrap.innerHTML = '
Erro ao carregar. Tente novamente.
';
});
}
// Invalida caches de slots e datas de um barbeiro após agendamento confirmado
// Invalida caches de slots e datas de um barbeiro após agendamento confirmado
function _invalidarCachesBarbeiro(emailBarbeiro) {
// Slots: chave "DS|email|durXXX" (nova) ou "DS|email" (legada)
Object.keys(_cacheSlotsMap).forEach(k => {
// Invalida tanto chaves novas (com |durXXX) quanto legadas (sem duração)
if (k.includes('|' + emailBarbeiro + '|') || k.endsWith('|' + emailBarbeiro)) delete _cacheSlotsMap[k];
});
// BUG 3 FIX: chave real é "email|durXXX", não apenas "email"
// O delete anterior nunca encontrava a chave e o cache ficava eternamente válido
Object.keys(_cacheDatasBarb).forEach(k => {
if (k.startsWith(emailBarbeiro + '|dur')) delete _cacheDatasBarb[k];
});
}
function montarSlots(horarios){
const wrap=document.getElementById('slotsWrap'); wrap.innerHTML='';
if(!horarios||!horarios.length){wrap.innerHTML='
Nenhum horário disponível nesta data.
';return;}
const[ds,ms,as]=dataSelecionada.split('/');
const dSel=new Date(as,ms-1,ds),tMid=new Date(hoje.getFullYear(),hoje.getMonth(),hoje.getDate());
const toM=h=>{const[a,b]=h.split(':').map(Number);return a*60+b;};
// ── FIX: extrai marcador __FIM__ injetado pela API como sentinela de fechamento ──
// A API adiciona "__FIM__HH:MM" ao final de livresComFechamento quando o último slot
// está ocupado, para que o frontend saiba o horário de fechamento mesmo sem ele nos livres.
// Separar esse marcador antes da detecção de bMin evita que o gap entre o último slot
// livre e o sentinela (que pode ser diferente de bMin) corrompa o cálculo de blocos.
//
// ── FIX PRINCIPAL: extrai __ALLSLOTS__ para distinção gap × slot ocupado ────────
// BUG anterior: o frontend bloqueava 15:30 porque 16:15 (endMin do serviço) não estava
// em slotTimeSet — slotTimeSet só contém slots LIVRES. Se 16:15 está ocupado por outro
// cliente, 16:15 não aparece, e 15:30 era incorretamente eliminado da lista.
// CORREÇÃO: a API envia __ALLSLOTS__ com TODOS os slots configurados (livres + ocupados).
// O frontend usa allSlotTimeSet para checar se endMin é um slot real ou um gap de almoço.
let _fimHora = null;
let _allSlotsSet = null;
let _bMinServidor = null;
let _horaFimReal = null; // NOVO: horário real de fechamento (cfg.hora_fim), não estimado
const horariosSemFim = horarios.filter(h => {
if(typeof h==='string' && h.startsWith('__FIM__')){
_fimHora = h.replace('__FIM__','');
return false;
}
if(typeof h==='string' && h.startsWith('__ALLSLOTS__')){
const ss = h.replace('__ALLSLOTS__','').split(',').filter(Boolean);
_allSlotsSet = new Set(ss.map(s=>{const[a,b]=s.split(':').map(Number);return a*60+b;}));
return false;
}
if(typeof h==='string' && h.startsWith('__BMIN__')){
const v = Number(h.replace('__BMIN__',''));
if (!isNaN(v) && v > 0) _bMinServidor = v;
return false;
}
if(typeof h==='string' && h.startsWith('__HORAFIM__')){
_horaFimReal = h.replace('__HORAFIM__','');
return false;
}
return true;
});
// Horário de fechamento = marcador extraído OU último slot da lista sem marcador
const ultimoSlotMin = _fimHora
? toM(_fimHora)
: (horariosSemFim.length>0 ? toM(horariosSemFim[horariosSemFim.length-1]) : 0);
// filt/slotTimeSet usam apenas slots reais (sem sentinela)
let filt=horariosSemFim;
if(dSel.getTime()===tMid.getTime()){const hh=hoje.getHours(),mm=hoje.getMinutes();filt=horariosSemFim.filter(h=>{const[a,b]=h.split(':').map(Number);return a>hh||(a===hh&&b>mm);});}
const svc=servicos[document.getElementById('servico').value];
const tempo=svc?Number(svc.tempo):30;
// ── FIX bMin: prioriza o valor REAL enviado pelo backend (__BMIN__). Só recorre
// à heurística de adivinhação (gap entre os 3 primeiros slots) quando o backend
// não mandou o sentinela (cache antigo de antes desse fix, ou versão desatualizada
// do worker) — essa heurística falha quando a grade tem um trecho irregular
// (ex: sub-slots de 15min dentro de uma grade de 30min), detectando bMin errado
// e rejeitando slots válidos em cascata.
let bMin=_intervaloConfig||30;
if (_bMinServidor) {
bMin = _bMinServidor;
} else if(horariosSemFim.length>=3){
const g1=toM(horariosSemFim[1])-toM(horariosSemFim[0]);
const g2=toM(horariosSemFim[2])-toM(horariosSemFim[1]);
if(g1===g2&&g1>=15&&g1<=120)bMin=g1;
} else if(horariosSemFim.length>=2){
// Com apenas 2 slots, só confia no gap se bate com _intervaloConfig (evita sentinela)
const gap=toM(horariosSemFim[1])-toM(horariosSemFim[0]);
if(gap>=15&&gap<=120&&gap===(_intervaloConfig||gap))bMin=gap;
}
const blocos=Math.ceil(tempo/bMin);
// FIX: Detecta gaps no meio do dia (ex: pausa de almoço)
// Constrói conjunto de todos os horários reais (sem sentinela) para checar se endMin cai num gap
const slotTimeSet=new Set(horariosSemFim.map(h=>toM(h)));
// FIX PRINCIPAL: usa _allSlotsSet (slots configurados, livres + ocupados) para detectar gaps.
// Sem isso, um slot S ficava bloqueado quando endMin(S) coincidia com um slot OCUPADO,
// pois slotTimeSet contém apenas livres. Ex: 15:30 era bloqueado porque 16:15 estava
// ocupado por Joao H., mesmo que o serviço de 15:30 não conflitasse com ele.
const checkEndSet = _allSlotsSet || slotTimeSet;
// Horário real de fechamento = cfg.hora_fim enviado pela API (__HORAFIM__).
// Só cai para a estimativa antiga (último slot + bMin) se o backend for
// uma versão antiga que ainda não manda esse sentinela.
const fechamentoReal = _horaFimReal ? toM(_horaFimReal) : (ultimoSlotMin + bMin);
const validos=filt.filter((h,i)=>{
const endMin=toM(h)+tempo; // usa tempo real do serviço, não blocos*bMin
// O serviço não pode terminar depois do horário real de fechamento
if(endMin>fechamentoReal)return false;
// NOTA: a verificação de "isso cai num gap de almoço" já foi feita no
// BACKEND (getHorariosPorBarbeiro), slot a slot, comparando com o próximo
// horário real configurado — de forma correta mesmo com grade irregular
// (slots_json curado, não uniforme). Reaplicar essa checagem aqui no
// frontend assumindo passos fixos de bMin (slotAnterior+bMin) rejeitava
// por engano serviços cuja duração não é múltiplo exato do intervalo
// (ex: serviço de 30/40min numa grade curada de 45min), porque o
// horário de término caía "no meio" de um vão livre válido e não batia
// com nenhum múltiplo de bMin — mesmo sem nenhum conflito real.
if(blocos<=1)return true;
// FIX: verifica blocos consecutivos usando allSlotTimeSet (todos os slots configurados,
// livres + ocupados) para os slots INTERMEDIÁRIOS.
// O serviço termina em endMin = início do próximo agendamento → não conflita.
// Ex: 08:00+90min termina às 09:30 (ocupado) → OK, não conflita com quem começa às 09:30.
//
// Se _allSlotsSet não veio da API (cache antigo), reconstrói inferindo a grade completa:
// percorre todos os slots esperados e aceita se existem em filt OU se o gap é consistente.
const allSetCheck = _allSlotsSet || (() => {
// Reconstrói grade completa: START_MIN até fechamentoReal em passos de bMin
const s = new Set();
for(let t = toM(horariosSemFim[0] || '08:00'); t <= fechamentoReal; t += bMin) s.add(t);
return s;
})();
for(let j=1;j1){const av=document.createElement('p');av.style.cssText='font-size:0.78rem;color:var(--muted);margin-bottom:8px;grid-column:1/-1;';av.textContent='ℹ️ Este serviço ocupa '+blocos+' horários de '+bMin+' min.';wrap.appendChild(av);}
validos.forEach(h=>{
const btn=document.createElement('div');
btn.className='slot'+(h===horarioSelecionado?' selected':'');
// FIX: mostra horário de término real do serviço (usa tempo exato, não múltiplo de slot)
const fim=toM(h)+tempo;
const fimStr=String(Math.floor(fim/60)).padStart(2,'0')+':'+String(fim%60).padStart(2,'0');
btn.textContent=h;
const sub=document.createElement('div');sub.style.cssText='font-size:0.68rem;opacity:0.7;margin-top:2px;';sub.textContent='até '+fimStr;btn.appendChild(sub);
btn.onclick=()=>{horarioSelecionado=h;document.querySelectorAll('.slot').forEach(b=>b.classList.remove('selected'));btn.classList.add('selected');atualizarResumo();document.getElementById('s3').className='step done';document.getElementById('line2').className='step-line done';};
wrap.appendChild(btn);
});
}
function atualizarResumo(){
const b=barbeiros[document.getElementById('barbeiro').value],s=servicos[document.getElementById('servico').value];
document.getElementById('r-barbeiro').textContent=b?b.nome:'—';
document.getElementById('r-servico').textContent=s?s.nome:'—';
document.getElementById('r-data').textContent=dataSelecionada||'—';
document.getElementById('r-horario').textContent=horarioSelecionado||'—';
document.getElementById('r-duracao').textContent=s?s.tempo+' min':'—';
document.getElementById('r-valor').textContent=s?'R$ '+s.valor:'—';
}
// ══════════════════════════════════════════════════════════════
// DADOS DO USUÁRIO
// ══════════════════════════════════════════════════════════════
document.getElementById('email').addEventListener('input',function(){
if(clienteEmail)return;
const b=document.getElementById('bloco-cadastro');
b.style.display=this.value.trim().length>3?'block':'none';
if(this.value.trim().length<=3)toggleCadastro(false);
});
function toggleCadastro(sim){
querCadastrar=sim;
document.getElementById('opc-sim').classList.toggle('active',sim);
document.getElementById('opc-nao').classList.toggle('active',!sim);
document.getElementById('campos-senha').style.display=sim?'flex':'none';
if(!sim){document.getElementById('senha').value='';document.getElementById('confirma').value='';}
}
// [SUBSTITUÍDO] api.run → gsr.call
function _carregarEPreencherDados(email){
gsr.call('buscarDadosCliente', [email])
.then(d => {
clienteNome=d.nome||''; clienteTelefone=d.telefone||'';
_aplicarDadosLogado();
if(clienteNome)document.getElementById('cli-nome-display').textContent=clienteNome;
// Agora que temos o telefone, carrega o card BarberVip+
if(clienteTelefone) _carregarBcCliente();
})
.catch(() => { _aplicarDadosLogado(); });
}
function _aplicarDadosLogado(){
const conteudoVisivel=document.getElementById('agendar-content').style.display!=='none';
if(!conteudoVisivel||!clienteEmail)return;
const banner=document.getElementById('banner-logado'); banner.style.display='flex';
document.getElementById('banner-logado-nome').textContent=clienteNome?'Olá, '+clienteNome+'!':'Bem-vindo de volta!';
document.getElementById('banner-logado-sub').textContent=clienteNome?clienteEmail:'Dados preenchidos automaticamente';
const campoNome=document.getElementById('nome'),campoTel=document.getElementById('telefone'),campoEmail=document.getElementById('email');
if(clienteNome&&!campoNome.value.trim())campoNome.value=clienteNome;
if(clienteTelefone&&!campoTel.value.trim())campoTel.value=clienteTelefone;
if(clienteEmail&&!campoEmail.value.trim())campoEmail.value=clienteEmail;
campoEmail.style.display='none';
document.getElementById('bloco-cadastro').style.display='none';
}
// ══════════════════════════════════════════════════════════════
// AGENDAMENTO
// ══════════════════════════════════════════════════════════════
let _agendaLinkGCal=null,_agendaICSBase64=null;
// ── Helpers para montar Google Calendar / .ics no CLIENTE ──────────────────
// (o backend nunca gerava res.linkGoogleCalendar / res.icsBase64 — por isso
// o botão "Adicionar à Agenda" nunca aparecia funcional. Geramos tudo aqui.)
// OBS: evitamos barra invertida literal no código porque este arquivo é
// reembutido em outro template literal no deploy, e um nivel de escaping
// e consumido nesse processo, quebrando regex/strings que dependam dela.
var _BS = String.fromCharCode(92); // barra invertida
var _LF = String.fromCharCode(10); // quebra de linha (LF)
var _CR = String.fromCharCode(13); // retorno de carro (CR)
function _parseDataHoraBR(dataStr, horaStr) {
const [d, m, y] = String(dataStr || '').split('/').map(Number);
const [hh, mm] = String(horaStr || '00:00').split(':').map(Number);
return new Date(y || new Date().getFullYear(), (m || 1) - 1, d || 1, hh || 0, mm || 0, 0, 0);
}
function _formatICSDate(date) {
const p = n => String(n).padStart(2, '0');
return date.getUTCFullYear() + p(date.getUTCMonth() + 1) + p(date.getUTCDate())
+ 'T' + p(date.getUTCHours()) + p(date.getUTCMinutes()) + p(date.getUTCSeconds()) + 'Z';
}
function _escapeICS(str) {
return String(str || '')
.split(_BS).join(_BS + _BS)
.split(';').join(_BS + ';')
.split(',').join(_BS + ',')
.split(_LF).join(_BS + 'n');
}
function _gerarLinksAgenda(nomeServico, nomeBarbeiro, dataStr, horaStr, duracaoMin) {
const inicio = _parseDataHoraBR(dataStr, horaStr);
const fim = new Date(inicio.getTime() + (Number(duracaoMin) || 30) * 60000);
var nomeLoja = document.title;
var _pos = nomeLoja.indexOf(' — '); if (_pos === -1) _pos = nomeLoja.indexOf(' - ');
if (_pos !== -1) nomeLoja = nomeLoja.substring(0, _pos);
nomeLoja = nomeLoja.trim() || 'Barbearia';
const titulo = nomeServico ? (nomeServico + ' — ' + nomeLoja) : nomeLoja;
const detalhes = 'Serviço: ' + (nomeServico || '-') + _LF + 'Barbeiro: ' + (nomeBarbeiro || '-') + _LF + 'Agendado via BarberOS+';
const linkGoogleCalendar = 'https://calendar.google.com/calendar/render?action=TEMPLATE'
+ '&text=' + encodeURIComponent(titulo)
+ '&dates=' + _formatICSDate(inicio) + '/' + _formatICSDate(fim)
+ '&details=' + encodeURIComponent(detalhes)
+ '&location=' + encodeURIComponent(nomeLoja);
const uid = 'barberos-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '@barberos';
const icsStr = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//BarberOS+//Agendamento//PT-BR', 'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
'UID:' + uid,
'DTSTAMP:' + _formatICSDate(new Date()),
'DTSTART:' + _formatICSDate(inicio),
'DTEND:' + _formatICSDate(fim),
'SUMMARY:' + _escapeICS(titulo),
'DESCRIPTION:' + _escapeICS(detalhes),
'LOCATION:' + _escapeICS(nomeLoja),
'BEGIN:VALARM', 'TRIGGER:-PT1H', 'ACTION:DISPLAY', 'DESCRIPTION:Lembrete de agendamento', 'END:VALARM',
'END:VEVENT', 'END:VCALENDAR'
].join(_CR + _LF);
let icsBase64 = '';
try { icsBase64 = btoa(unescape(encodeURIComponent(icsStr))); } catch (e) { try { icsBase64 = btoa(icsStr); } catch (e2) { icsBase64 = ''; } }
return { linkGoogleCalendar, icsBase64 };
}
// ── Lembrete via notificação do navegador, 1h antes (best-effort) ─────────
// Só dispara enquanto esta aba/site ficar aberta — navegador fechado não
// recebe. Para funcionar com o site fechado seria necessário Push real
// (Service Worker + VAPID + agendamento no servidor).
// Retorna: 'agendado' | 'pendente' (aguardando permissão) | 'passou' | 'muito_longe' | 'negado' | 'erro'
function _agendarLembreteNavegador(nomeServico, nomeBarbeiro, dataStr, horaStr) {
try {
if (!('Notification' in window)) return 'erro';
const inicio = _parseDataHoraBR(dataStr, horaStr);
const msAlvo = inicio.getTime() - 60 * 60 * 1000; // 1h antes
const msEspera = msAlvo - Date.now();
const MAX_TIMEOUT = 2147000000; // limite prático do setTimeout (~24.8 dias)
if (msEspera <= 0) return 'passou';
if (msEspera > MAX_TIMEOUT) return 'muito_longe';
const disparar = () => {
try {
new Notification('⏰ Seu horário é daqui a 1 hora!', {
body: (nomeServico || 'Agendamento') + (nomeBarbeiro ? (' com ' + nomeBarbeiro) : '') + ' às ' + horaStr,
});
} catch (e) {}
};
const programar = () => setTimeout(disparar, msEspera);
if (Notification.permission === 'granted') { programar(); return 'agendado'; }
if (Notification.permission === 'denied') return 'negado';
Notification.requestPermission().then(p => { if (p === 'granted') programar(); });
return 'pendente';
} catch (e) { return 'erro'; }
}
function fecharModalAgenda(){document.getElementById('agendaModal').classList.remove('open');}
let _agendaDadosAtual = null;
function abrirModalAgenda(res,dadosAgen){
document.getElementById('am-servico').textContent=dadosAgen.servico||'—';
document.getElementById('am-barbeiro').textContent=dadosAgen.barbeiro||'—';
document.getElementById('am-data').textContent=dadosAgen.data||'—';
document.getElementById('am-horario').textContent=dadosAgen.horario||'—';
const btnGCal=document.getElementById('btn-google-cal');
if(res.linkGoogleCalendar){btnGCal.href=res.linkGoogleCalendar;btnGCal.style.display='flex';}else{btnGCal.style.display='none';}
const btnICS=document.getElementById('btn-ics-cal');
if(res.icsBase64){btnICS.href='data:text/calendar;base64,'+res.icsBase64;btnICS.style.display='flex';}else{btnICS.style.display='none';}
_agendaDadosAtual = dadosAgen;
const tituloLembrete = document.getElementById('btn-lembrete-nav-titulo');
const subLembrete = document.getElementById('btn-lembrete-nav-sub');
if (tituloLembrete) tituloLembrete.textContent = 'Avisar no navegador';
if (subLembrete) subLembrete.textContent = 'Alerta pop-up 1h antes (com a aba aberta)';
document.getElementById('agendaModal').classList.add('open');
}
function ativarLembreteNavegador(){
const tituloEl = document.getElementById('btn-lembrete-nav-titulo');
const subEl = document.getElementById('btn-lembrete-nav-sub');
if (!_agendaDadosAtual || !_agendaDadosAtual.data || !_agendaDadosAtual.horario) {
if (subEl) subEl.textContent = 'Não foi possível identificar data/horário.';
return;
}
if (!('Notification' in window)) {
if (subEl) subEl.textContent = 'Seu navegador não suporta essa notificação.';
return;
}
if (Notification.permission === 'denied') {
if (subEl) subEl.textContent = 'Permissão bloqueada — habilite notificações nas configurações do navegador.';
return;
}
const resultado = _agendarLembreteNavegador(_agendaDadosAtual.servico, _agendaDadosAtual.barbeiro, _agendaDadosAtual.data, _agendaDadosAtual.horario);
if (resultado === 'agendado') {
if (tituloEl) tituloEl.textContent = '✅ Lembrete ativado';
if (subEl) subEl.textContent = 'Vamos te avisar 1h antes, com esta aba aberta.';
} else if (resultado === 'pendente') {
if (subEl) subEl.textContent = 'Aguardando permissão do navegador...';
} else if (resultado === 'passou') {
if (subEl) subEl.textContent = 'Já falta menos de 1h — sem tempo para avisar antes.';
} else if (resultado === 'muito_longe') {
if (subEl) subEl.textContent = 'Data muito distante — volte aqui mais perto do dia.';
} else if (resultado === 'negado') {
if (subEl) subEl.textContent = 'Permissão negada.';
} else {
if (subEl) subEl.textContent = 'Não foi possível ativar o lembrete.';
}
}
// [SUBSTITUÍDO] api.run → gsr.call
function agendar(){
const nome=document.getElementById('nome').value.trim();
const tel=document.getElementById('telefone').value.trim();
const email=clienteEmail||document.getElementById('email').value.trim();
const obsCliente=(document.getElementById('obs-cliente')?.value||'').trim();
const senha=document.getElementById('senha')?document.getElementById('senha').value:'';
const conf=document.getElementById('confirma')?document.getElementById('confirma').value:'';
const svc=servicos[document.getElementById('servico').value];
const barb=barbeiros[document.getElementById('barbeiro').value];
if(!_servicoEscolhido||!svc){SW('Escolha um serviço','Toque no campo de serviço e selecione o serviço desejado antes de continuar.','warning');return;}
if(!nome||!tel){SW('Preencha nome e telefone','','warning');return;}
if(!validarTel(tel)){SW('Telefone invalido','Informe um telefone com DDD. Ex: (85) 99999-9999','warning');return;}
if(!dataSelecionada){SW('Selecione uma data','','warning');return;}
if(!horarioSelecionado){SW('Selecione um horario','','warning');return;}
if(!clienteEmail&&querCadastrar){
if(!email){SW('Informe o e-mail','O e-mail e necessario para criar sua conta.','warning');return;}
if(senha.length<6){SW('Senha muito curta','Minimo 6 caracteres.','warning');return;}
if(senha!==conf){SW('As senhas nao coincidem','','warning');return;}
}
const bMin=_intervaloConfig||30,hOcup=[];
let[h,m]=horarioSelecionado.split(':').map(Number);
// FIX: marca todos os slots da grade que o serviço ocupa (startMin até startMin+tempo, passo bMin)
{const startMin=h*60+m,endMin=startMin+Number(svc.tempo);for(let t=startMin;t {
Swal.fire({
icon: 'warning', title: '⏰ Horário não disponível',
html: '
'
+ 'Este horário foi ocupado por outro agendamento '
+ 'enquanto você finalizava o formulário.
'
+ 'Por favor, escolha outro horário.'
+ '
',
background: '#0e1220', color: '#e8edf8',
confirmButtonColor: '#4f8ef7', confirmButtonText: 'Escolher outro horário',
});
horarioSelecionado = '';
document.querySelectorAll('.slot').forEach(s => s.classList.remove('selected'));
document.getElementById('s3').className = 'step';
document.getElementById('line2').className = 'step-line';
const _chInv = (barb.email && barb.email.trim()) ? barb.email.trim() : barb.nome;
_invalidarCachesBarbeiro(_chInv);
recarregarDatasParaBarbeiro(_chInv);
if (dataSelecionada) carregarSlots(dataSelecionada);
atualizarResumo();
};
_pSlotsVal.then(_slotsAtuais => {
// ── Extrai sentinelas da resposta fresca da API ──────────────────────────────
// __ALLSLOTS__ = todos os slots configurados (livres + ocupados), usado para
// derivar o bMin REAL da grade, independente de _intervaloConfig.
// __FIM__ = horário de fechamento (não usado aqui, só filtrado).
let _valAllSlots = null;
const _livresArr = (_slotsAtuais || []).filter(s => {
if (typeof s !== 'string') return false;
if (s.startsWith('__ALLSLOTS__')) { _valAllSlots = s.replace('__ALLSLOTS__', '').split(',').filter(Boolean); return false; }
if (s.startsWith('__FIM__')) return false;
return true;
});
const _livres = new Set(_livresArr);
// ── Deriva bMin REAL a partir do __ALLSLOTS__ retornado pela API ─────────────
// Isso evita usar _intervaloConfig (que pode estar desatualizado ou errado)
// e garante que hOcup seja reconstruído com o passo correto da grade.
let _bMinReal = _intervaloConfig || 30;
if (_valAllSlots && _valAllSlots.length >= 2) {
const _toM = s => { const [a,b] = s.split(':').map(Number); return a*60+b; };
const _gap = _toM(_valAllSlots[1]) - _toM(_valAllSlots[0]);
if (_gap >= 10 && _gap <= 120) _bMinReal = _gap;
}
// ── Reconstrói hOcup com o bMin REAL para enviar ao servidor ───────────────
// O hOcup original (linha 4675) usa _intervaloConfig que pode diferir do bMin
// real da grade (ex: _intervaloConfig=30 mas slots são de 40min → hOcup errado).
const _hh = Number(horarioSelecionado.split(':')[0]);
const _mm = Number(horarioSelecionado.split(':')[1]);
const _startMin = _hh * 60 + _mm;
const _endMin = _startMin + Number(svc.tempo);
const _hOcupReal = [];
for (let t = _startMin; t < _endMin; t += _bMinReal) {
_hOcupReal.push(String(Math.floor(t/60)).padStart(2,'0') + ':' + String(t%60).padStart(2,'0'));
}
// Atualiza hOcup para o servidor usar o passo correto da grade
hOcup.length = 0; _hOcupReal.forEach(s => hOcup.push(s));
// ── Verifica apenas se o horário INICIAL está nos slots livres ───────────────
// _livres contém slots onde a API aprovou aquele horário como START de um serviço
// de duração svc.tempo. Os slots intermediários (hOcupReal[1], [2]...) NÃO devem
// ser checados em _livres: a API os exclui de _livres porque como START gerariam
// overlap com o próximo agendamento — mas como slots intermediários do serviço
// atual eles são válidos (o serviço já termina antes do próximo).
// Exemplo: 08:40+80min=10:00 (OK). "09:20" não está em _livres (09:20+80=10:40
// colide com Patrick), mas isso não importa para quem está agendando às 08:40.
if (!_livres.has(horarioSelecionado)) { _mostrarHorarioOcupado(); return; }
// ── Slots confirmados livres → envia para o servidor ─────────────────────
SWL('Confirmando...');
gsr.call('salvarAgendamentoVerificando', [{nome,telefone:tel,email,servico:svc.nome,barbeiro:barb.nome,barbeiro_email:barb.email||'',data:dataSelecionada,horarios:hOcup,forcar:false,cadastrar:!clienteEmail&&querCadastrar,senha:(!clienteEmail&&querCadastrar)?senha:'',obs:obsCliente}])
.then(res => {
Swal.close();
if (!res || res.conflito) {
_mostrarHorarioOcupado();
return;
}
mostrarWhatsAppFab();
// ── Gera Google Calendar / .ics e guarda um snapshot dos dados AGORA,
// ── enquanto svc/barb/dataSelecionada/horarioSelecionado ainda têm os
// ── valores certos (são zerados mais abaixo, antes do popup ser fechado) ──
let _dadosParaModalSnapshot = { servico: svc.nome, barbeiro: barb ? barb.nome : '', data: dataSelecionada, horario: horarioSelecionado };
try {
const _linksAgenda = _gerarLinksAgenda(svc.nome, barb ? barb.nome : '', dataSelecionada, horarioSelecionado, svc.tempo);
_agendaLinkGCal = _linksAgenda.linkGoogleCalendar;
_agendaICSBase64 = _linksAgenda.icsBase64;
} catch (eAgenda) { _agendaLinkGCal = null; _agendaICSBase64 = null; }
// ── Monta mensagem WhatsApp com os dados do agendamento ──────────
var _LF = String.fromCharCode(10);
var _nomeConf = (document.getElementById('nome') ? document.getElementById('nome').value.trim() : '') || clienteNome || '';
var _servicoConf = document.getElementById('r-servico') ? document.getElementById('r-servico').textContent : '';
var _barbeiroConf = document.getElementById('r-barbeiro') ? document.getElementById('r-barbeiro').textContent : '';
var _dataConf = document.getElementById('r-data') ? document.getElementById('r-data').textContent : '';
var _horarioConf = document.getElementById('r-horario') ? document.getElementById('r-horario').textContent : '';
var _emailConf = clienteEmail || (document.getElementById('email') ? document.getElementById('email').value.trim() : '') || '';
var _telConf = (document.getElementById('telefone') ? document.getElementById('telefone').value.trim() : '') || clienteTelefone || '';
var _msgWa = 'Agendamento Confirmado!' + _LF + _LF
+ 'Cliente: ' + _nomeConf + _LF
+ 'Servico: ' + _servicoConf + _LF
+ 'Barbeiro: ' + _barbeiroConf + _LF
+ 'Data: ' + _dataConf + _LF
+ 'Horario: ' + _horarioConf
+ (_telConf ? _LF + 'Telefone: ' + _telConf : '')
+ _LF + _LF + 'Agendado via BarberOS+';
var _whatsNumConf = WHATS_BARBEARIA || WHATS_LOJA;
var _whatsUrl = _whatsNumConf ? 'https://wa.me/' + _whatsNumConf + '?text=' + encodeURIComponent(_msgWa) : 'https://wa.me/?text=' + encodeURIComponent(_msgWa);
// ── HTML dos botões de confirmação ────────────────────────────────
let _botoesHtml = '
'
+ (res.cadastroRealizado
? 'Conta criada! Acesse Meus Agendamentos com seu e-mail e senha.'
: res.cadastroJaExiste
? 'E-mail ja cadastrado — agendamento vinculado à sua conta.'
: _emailConf
? 'Confirmação enviada para ' + _emailConf + '.'
: 'Seu horário foi confirmado com sucesso.')
+ '
'
+ '
';
if (res.agendamento_id && _mpConectadoGlobal) {
var _valorSvc = Number(svc.valor || 0);
_botoesHtml += '';
}
if (_whatsUrl) {
_botoesHtml += ''
+ ''
+ 'Confirmar pelo WhatsApp';
}
if (res.agendamento_id && 'serviceWorker' in navigator && 'PushManager' in window) {
var _pushEmail = (_emailConf || '').replace(/["'<>]/g, '');
var _pushTel = (_telConf || '').replace(/["'<>]/g, '');
// Não ativa mais sozinho (isso dava timeout/erro em vários
// navegadores, já que pedir permissão de notificação sem um clique
// direto do usuário é bloqueado/atrasado por eles). Em vez disso,
// só chama atenção com um aviso pra lembrar de clicar.
_botoesHtml += '
'
+ '👇 Não esqueça de ativar o aviso abaixo pra não perder o horário!
';
_botoesHtml += '';
}
_botoesHtml += '
';
Swal.fire({
icon: 'success',
title: 'Agendamento realizado!',
html: _botoesHtml,
background: '#0e1220', color: '#e8edf8', confirmButtonColor: '#4f8ef7',
showCancelButton: true,
confirmButtonText: 'Adicionar à Agenda',
cancelButtonText: 'Fechar',
cancelButtonColor: '#1e2a45',
}).then(function(r) {
if (r.isConfirmed && (_agendaLinkGCal || _agendaICSBase64)) {
abrirModalAgenda({ linkGoogleCalendar: _agendaLinkGCal, icsBase64: _agendaICSBase64 }, _dadosParaModalSnapshot);
}
const emailUsado = clienteEmail || document.getElementById('email').value.trim();
if (res.cadastroRealizado || res.cadastroJaExiste) {
clienteEmail = emailUsado;
entrarAreaCli(emailUsado);
mostrarSecao('cliente');
} else if (clienteEmail) {
carregarAgenCli();
mostrarSecao('cliente');
}
});
horarioSelecionado = ''; dataSelecionada = '';
document.getElementById('slotsWrap').innerHTML = '';
document.getElementById('card-horario').style.display = 'none';
document.getElementById('s3').className = 'step';
document.getElementById('line2').className = 'step-line';
const bAtual = barbeiros[document.getElementById('barbeiro').value];
if (bAtual) {
_invalidarCachesBarbeiro(bAtual.email || bAtual.nome); // agendamento confirmado → dados desatualizados
recarregarDatasParaBarbeiro(bAtual.email || bAtual.nome);
}
else renderCalendar();
atualizarResumo();
if (!clienteEmail) {
['nome','telefone','email','senha','confirma'].forEach(function(id){
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('bloco-cadastro').style.display = 'none';
toggleCadastro(false);
} else {
document.getElementById('telefone').value = clienteTelefone || '';
document.getElementById('nome').value = clienteNome || '';
}
const obsEl = document.getElementById('obs-cliente'); if (obsEl) obsEl.value = '';
})
.catch(err => { Swal.close(); SW('Erro',err.message||'Falha ao agendar.','error'); });
}).catch(err => { Swal.close(); SW('Erro ao verificar horário',err.message||'Tente novamente.','error'); });
}
// ══════════════════════════════════════════════════════════════
// CLIENTE
// ══════════════════════════════════════════════════════════════
['cli-email','cli-senha'].forEach(id=>document.getElementById(id).addEventListener('keydown',e=>{if(e.key==='Enter')clienteLogin();}));
['cad-nome','cad-telefone','cad-email','cad-senha','cad-confirma'].forEach(id=>{const el=document.getElementById(id);if(el)el.addEventListener('keydown',e=>{if(e.key==='Enter')clienteCadastrar();});});
function _setBtnLoading(id,loading,orig){
const btn=document.getElementById(id);if(!btn)return;
if(loading){btn.disabled=true;btn.dataset.orig=btn.textContent;btn.innerHTML=' Aguarde...';}
else{btn.disabled=false;btn.textContent=btn.dataset.orig||orig||'Enviar';}
}
// [SUBSTITUÍDO] api.run → gsr.call
function clienteLogin(){
const email=document.getElementById('cli-email').value.trim();
const senha=document.getElementById('cli-senha').value;
const err=document.getElementById('cli-err');err.style.display='none';
if(!email||!senha){err.textContent='Preencha e-mail e senha.';err.style.display='block';return;}
_setBtnLoading('btn-login',true);
gsr.call('validarLoginCliente', [email, senha])
.then(ok => {
_setBtnLoading('btn-login',false,'Entrar');
if(ok && ok.valido){ clienteEmail=email; entrarAreaCli(email); }
else{err.textContent='E-mail ou senha incorretos.';err.style.display='block';}
})
.catch(() => {
_setBtnLoading('btn-login',false,'Entrar');
SW('Erro','Falha na conexão.','error');
});
}
// [SUBSTITUÍDO] api.run → gsr.call
function clienteCadastrar(){
const nome=document.getElementById('cad-nome').value.trim();
const telefone=document.getElementById('cad-telefone').value.trim();
const email=document.getElementById('cad-email').value.trim();
const senha=document.getElementById('cad-senha').value;
const conf=document.getElementById('cad-confirma').value;
const err=document.getElementById('cad-err');err.style.display='none';
if(!nome){err.textContent='Informe seu nome completo.';err.style.display='block';return;}
if(!telefone){err.textContent='Informe seu telefone.';err.style.display='block';return;}
if(!validarTel(telefone)){err.textContent='Telefone inválido. Use o formato (XX) XXXXX-XXXX.';err.style.display='block';return;}
if(!email||!senha){err.textContent='Preencha todos os campos.';err.style.display='block';return;}
if(senha.length<6){err.textContent='Senha deve ter mínimo 6 caracteres.';err.style.display='block';return;}
if(senha!==conf){err.textContent='As senhas não coincidem.';err.style.display='block';return;}
_setBtnLoading('btn-cadastrar',true);
gsr.call('cadastrarUsuario', [email, senha, nome, telefone])
.then(msg => {
_setBtnLoading('btn-cadastrar',false,'Criar Conta');
if(msg.includes('sucesso')){clienteEmail=email;clienteNome=nome;clienteTelefone=telefone;entrarAreaCli(email);}
else{err.textContent=msg;err.style.display='block';}
})
.catch(() => {
_setBtnLoading('btn-cadastrar',false,'Criar Conta');
SW('Erro','Falha ao criar conta.','error');
});
}
// [SUBSTITUÍDO] api.run → gsr.call
function entrarAreaCli(email){
clienteEmail=email;sessaoSalvar(email);mostrarSubCli('area');
const nomeTemp=email.split('@')[0].replace(/[._]/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
document.getElementById('cli-nome-display').textContent=clienteNome||nomeTemp;
document.getElementById('cli-email-display').textContent=email;
if(!clienteNome){_carregarEPreencherDados(email);}else{_aplicarDadosLogado();}
carregarAgenCli();
_carregarBcCliente();
gsr.call('getTemaBarbearia')
.then(data => { aplicarTema(data?.tema || 'padrao'); })
.catch(() => {});
}
// ── BarberVip+ no index do cliente ────────────────────────────────
function _carregarBcCliente() {
const tel = clienteTelefone ? clienteTelefone.replace(/D/g,'') : '';
// Sempre tenta buscar — passa email como fallback quando não tem telefone
// Se ainda não buscou os dados do cliente, busca antes para ter o telefone
if (!tel && clienteEmail) {
gsr.call('buscarDadosCliente', [clienteEmail])
.then(function(d) {
if (d && d.telefone) clienteTelefone = d.telefone;
// Chama de novo — agora com telefone (ou só com email se não tiver)
const telAtual = clienteTelefone ? clienteTelefone.replace(/D/g,'') : '';
gsr.call('getBarberCoinPontos', [telAtual, '', clienteEmail])
.then(function(res) { _renderBcCard(res); })
.catch(function() { const c=document.getElementById('cli-bc-card'); if(c)c.style.display='none'; });
}).catch(function(){});
return;
}
gsr.call('getBarberCoinPontos', [tel, '', clienteEmail || ''])
.then(function(res) { _renderBcCard(res); })
.catch(function(){
const card = document.getElementById('cli-bc-card');
if (card) card.style.display = 'none';
});
}
function mascaraTel(input) {
let v = input.value.replace(/D/g, '').slice(0, 11);
if (v.length <= 10) {
v = v.replace(/^(d{2})(d{4})(d{0,4})/, '($1) $2-$3');
} else {
v = v.replace(/^(d{2})(d{5})(d{0,4})/, '($1) $2-$3');
}
input.value = v;
}
function _renderBcCard(res) {
const card = document.getElementById('cli-bc-card');
if (!card) return;
if (!res || !res.programaAtivo || !res.encontrado) { card.style.display='none'; return; }
const pontos = res.saldo || 0;
const meta = res.meta || 10;
const pronto = pontos >= meta;
const isLivre = (res.modo || 'cartela') === 'livre';
// Badge
const badge = document.getElementById('cli-bc-badge');
if (badge) badge.textContent = isLivre ? pontos + ' 🪙' : pontos + ' / ' + meta;
// Barra de progresso
const bar = document.getElementById('cli-bc-bar');
if (bar) {
bar.style.width = Math.min(100, Math.round((pontos/meta)*100)) + '%';
bar.style.background = pronto ? '#f5a623' : 'var(--accent)';
}
// Stamps (cartela) ou texto livre
const stampsEl = document.getElementById('cli-bc-stamps');
if (stampsEl) {
if (isLivre) {
stampsEl.innerHTML = 'Acumule ' + meta + ' 🪙 para ganhar o prêmio';
} else {
let html = '';
for (let i = 0; i < meta; i++) {
const cheio = i < pontos;
html += '
'+(cheio?'🪙':'')+'
';
}
stampsEl.innerHTML = html;
}
}
// Prêmio conquistado
const premioWrap = document.getElementById('cli-bc-premio-wrap');
const premioNome = document.getElementById('cli-bc-premio-nome');
if (premioWrap) premioWrap.style.display = pronto ? 'block' : 'none';
if (premioNome && res.premio) premioNome.textContent = res.premio;
card.style.display = 'block';
}
function validarTel(tel){const n=tel.replace(/D/g,'');return n.length===10||n.length===11;}
function clienteLogout(){
clienteEmail='';clienteNome='';clienteTelefone='';agendamentosCliente=[];sessaoLimpar();
['nome','telefone'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
document.getElementById('email').style.display='';document.getElementById('email').value='';
document.getElementById('banner-logado').style.display='none';
document.getElementById('bloco-cadastro').style.display='none';
document.getElementById('cli-email').value='';document.getElementById('cli-senha').value='';
document.getElementById('cli-err').style.display='none';
mostrarSubCli('login');
Swal.fire({toast:true,position:'top-end',icon:'info',title:'Sessão encerrada',showConfirmButton:false,timer:2200,background:'#0e1220',color:'#e8edf8'});
}
function toggleSenha(){
const f=document.getElementById('cli-form-senha');
// Fecha o formulário de perfil se estiver aberto
const fp=document.getElementById('cli-form-perfil');
if(fp && fp.style.display!=='none') togglePerfil();
f.style.display=f.style.display==='none'?'block':'none';
if(f.style.display==='none'){['s-atual','s-nova','s-confirma'].forEach(id=>document.getElementById(id).value='');document.getElementById('s-err').style.display='none';}
}
// Exibe/oculta campo de senha — usado nos formulários de login e cadastro
function perfilToggleSenha(id, btn) {
const input = document.getElementById(id);
if (!input) return;
const mostrar = input.type === 'password';
input.type = mostrar ? 'text' : 'password';
btn.textContent = mostrar ? '🙈' : '👁';
}
// [SUBSTITUÍDO] api.run → gsr.call
function clienteAlterarSenha(){
const atual=document.getElementById('s-atual').value;
const nova=document.getElementById('s-nova').value;
const conf=document.getElementById('s-confirma').value;
const err=document.getElementById('s-err');err.style.display='none';
if(!atual||!nova){err.textContent='Preencha todos os campos.';err.style.display='block';return;}
if(nova.length<6){err.textContent='Nova senha deve ter mínimo 6 caracteres.';err.style.display='block';return;}
if(nova!==conf){err.textContent='As senhas não coincidem.';err.style.display='block';return;}
SWL('Alterando...');
gsr.call('alterarSenhaCliente', [clienteEmail, atual, nova])
.then(ok => {
Swal.close();
if(ok){SW('Senha alterada!','','success');toggleSenha();}
else{err.textContent='Senha atual incorreta.';err.style.display='block';}
})
.catch(() => { Swal.close(); SW('Erro','','error'); });
}
// ── Painel de "Notificações e avisos" na Conta do cliente ────────────
function toggleNotifCli(){
const f=document.getElementById('cli-form-notif');
// Fecha os outros formulários se estiverem abertos
const fp=document.getElementById('cli-form-perfil'); if(fp && fp.style.display!=='none') togglePerfil();
const fs=document.getElementById('cli-form-senha'); if(fs && fs.style.display!=='none') toggleSenha();
const abrindo=f.style.display==='none';
f.style.display=abrindo?'block':'none';
if(abrindo) _atualizarStatusNotifCli();
}
function _atualizarStatusNotifCli(){
const el=document.getElementById('cli-notif-status');
const btn=document.getElementById('btn-ativar-notif-todos');
if(!el) return;
const suportado=('serviceWorker' in navigator)&&('PushManager' in window)&&(typeof Notification!=='undefined');
if(!suportado){
el.innerHTML='⚠️ Seu navegador não suporta notificações push. Tenta pelo Chrome ou Safari mais recente.';
if(btn) btn.style.display='none';
return;
}
if(btn) btn.style.display='';
if(Notification.permission==='denied'){
el.innerHTML='🔒 As notificações estão bloqueadas pra esse site. Toque no 🔒 (ou ⓘ) ao lado do endereço no navegador → Notificações → Permitir, e depois clique no botão abaixo de novo.';
} else if(Notification.permission==='granted'){
const pend=_contarAgendamentosSemPush();
el.innerHTML = pend>0
? '🟡 Notificações permitidas, mas você tem '+pend+' agendamento(s) futuro(s) sem aviso ativado ainda.'
: '✅ Notificações ativadas — você será avisado 1h antes de cada horário marcado.';
} else {
el.innerHTML='⚪ Notificações ainda não configuradas nesse navegador.';
}
}
function _contarAgendamentosSemPush(){
const tMid=new Date(hoje.getFullYear(),hoje.getMonth(),hoje.getDate());
return (agendamentosCliente||[]).filter(function(a){
const[d,m,y]=(a.data||'').split('/'); const dt=y?new Date(y,m-1,d):null;
const st=(a.status||'').toLowerCase();
const futuro=st!=='cancelado'&&st!=='concluído'&&st!=='concluido'&&dt&&dt>=tMid;
if(!futuro||!a.id) return false;
let ja=false; try{ja=!!localStorage.getItem('push_ativo_ag_'+a.id);}catch(_e){}
return !ja;
}).length;
}
// Ativa o aviso push pra TODOS os agendamentos futuros de uma vez — sem
// precisar clicar um por um em cada card da lista de "Próximos".
async function ativarTodasNotificacoesCli(){
const btn=document.getElementById('btn-ativar-notif-todos');
const tMid=new Date(hoje.getFullYear(),hoje.getMonth(),hoje.getDate());
const pendentes=(agendamentosCliente||[]).filter(function(a){
const[d,m,y]=(a.data||'').split('/'); const dt=y?new Date(y,m-1,d):null;
const st=(a.status||'').toLowerCase();
const futuro=st!=='cancelado'&&st!=='concluído'&&st!=='concluido'&&dt&&dt>=tMid;
if(!futuro||!a.id) return false;
let ja=false; try{ja=!!localStorage.getItem('push_ativo_ag_'+a.id);}catch(_e){}
return !ja;
});
if(!pendentes.length){
Swal.fire({toast:true,position:'top-end',icon:'info',title:'Nada pra ativar',text:'Todos os seus agendamentos futuros já têm aviso ativado.',showConfirmButton:false,timer:3000,background:'#0e1220',color:'#e8edf8'});
return;
}
if(btn){ btn.disabled=true; btn.textContent='Ativando...'; }
let sucesso=0;
for(const a of pendentes){
try{
await ativarNotificacaoPush(a.id, clienteEmail, clienteTelefone, null);
// ativarNotificacaoPush não lança erro — checa a marca no localStorage
let ok=false; try{ok=!!localStorage.getItem('push_ativo_ag_'+a.id);}catch(_e){}
if(ok) sucesso++;
}catch(e){ console.error('[push todos]', e); }
}
if(btn){ btn.disabled=false; btn.textContent='Ativar para meus agendamentos'; }
_atualizarStatusNotifCli();
renderAgenCli();
if(sucesso===pendentes.length){
Swal.fire({toast:true,position:'top-end',icon:'success',title:'Notificações ativadas!',text:'Aviso ativado pra '+sucesso+' agendamento(s).',showConfirmButton:false,timer:3500,background:'#0e1220',color:'#e8edf8'});
} else if(sucesso>0){
Swal.fire({toast:true,position:'top-end',icon:'warning',title:'Parcialmente ativado',text:sucesso+' de '+pendentes.length+' agendamento(s) — o navegador pode ter bloqueado o restante.',showConfirmButton:false,timer:4000,background:'#0e1220',color:'#e8edf8'});
} else {
Swal.fire({toast:true,position:'top-end',icon:'error',title:'Não deu pra ativar',text:'Verifique se as notificações estão permitidas pra esse site.',showConfirmButton:false,timer:4000,background:'#0e1220',color:'#e8edf8'});
}
}
function mudarTabCli(tab){
tabCli=tab;
document.getElementById('tab-prox').classList.toggle('active',tab==='proximos');
document.getElementById('tab-hist').classList.toggle('active',tab==='historico');
renderAgenCli();
}
// [SUBSTITUÍDO] api.run → gsr.call
function carregarAgenCli(){
document.getElementById('cli-lista').innerHTML=`
`;
return;
}
el.innerHTML=lista.map(function(a){
var sc=(a.status||'').toLowerCase().replace('í','i').replace('ã','a').replace(' ','-');
var pC=['agendado','confirmado'].includes((a.status||'').toLowerCase());
var obs=a.observacao&&a.observacao!=='undefined'?a.observacao:'';
var _st=(a.status||'').toLowerCase().replace('í','i');
var _c=(_st==='concluido');
var _ja=false;try{_ja=a.id?!!localStorage.getItem('aval_'+a.id):false;}catch(_e){}
var btnAval=(_c&&!_ja)?'':'';
var btnCancel=pC?'':'';
// ── Botão pra ativar o aviso push desse agendamento específico ──
// Só aparece se: navegador suporta push, o agendamento ainda está
// agendado/confirmado (futuro) e ainda não foi ativado antes (marcação
// fica salva no localStorage pra não repetir depois de já ter ativado).
var btnNotif='';
var _pushSuportado=('serviceWorker' in navigator)&&('PushManager' in window)&&(typeof Notification!=='undefined');
var _jaAtivouPush=false;try{_jaAtivouPush=a.id?!!localStorage.getItem('push_ativo_ag_'+a.id):false;}catch(_e){}
if(pC&&a.id&&_pushSuportado&&!_jaAtivouPush){
var _bloqueado=(Notification.permission==='denied');
var _corNotif=_bloqueado?'var(--muted)':'var(--accent)';
var _txtNotif=_bloqueado?'🔔 Notificação bloqueada':'🔔 Avisar 1h antes';
btnNotif='';
}
var html='
';
return html;
}).join('');
}
function _d2n(s){if(!s)return 0;const[d,m,y]=s.split('/');return parseInt(y+m+d)||0;}
function cancelarAgen(idx,numero,id){
const a=agendamentosCliente[idx];
const ident=(numero&&numero!=='null'&&numero!=='undefined'&&numero!=='')?numero:String(id||'');
window._cancelPendente = { a, ident };
Swal.fire({
title:'Antes de cancelar…',
html:`
Nosso barbeiro se organiza com antecedência pra te atender. Cancelamentos de última hora dificultam o atendimento de outras pessoas — cancele só se realmente não puder comparecer.
${a.servico||''} — ${a.data||''} às ${a.hora||a.horario||'—'}
`,
showConfirmButton:false,
showCancelButton:false,
width:380
});
}
const _MOTIVOS_CANCEL = [
'Preciso remarcar para outro dia/horário',
'Não vou mais poder comparecer',
'Tive um imprevisto',
'Encontrei outra opção',
'Outro'
];
function _motivoCancelamento(){
const pend = window._cancelPendente; if(!pend) return;
const opts = _MOTIVOS_CANCEL.map(function(m){
return ``;
}).join('');
Swal.fire({
title:'Motivo do cancelamento',
html:`
Nos ajuda a melhorar — sua resposta é opcional.
${opts}
`,
showCancelButton:true,
confirmButtonText:'Enviar e cancelar',
cancelButtonText:'Pular e cancelar',
confirmButtonColor:'#f87171',
reverseButtons:true
}).then(r=>{
// Fechou no X/ESC/clique fora sem escolher nada → aborta, não cancela
if(!r.isConfirmed && r.dismiss!=='cancel') return;
let motivo='';
if(r.isConfirmed){
const sel=document.querySelector('input[name="motivo-cancel-op"]:checked');
const outro=(document.getElementById('motivo-cancel-outro')?.value||'').trim();
motivo=outro||(sel?sel.value:'');
}
_executarCancelamento(pend.a, pend.ident, motivo);
});
}
function _executarCancelamento(a, ident, motivo){
SWL('Cancelando...');
gsr.call('cancelarAgendamentoPorNumero', [ident, motivo])
.then(res => {
Swal.close();
SW('Cancelado!','Seu agendamento foi cancelado.','success');
carregarAgenCli();
})
.catch(e => { Swal.close(); SW('Erro',e?.message||'Falha ao cancelar.','error'); });
}
// ══════════════════════════════════════════════════════════════
// AVALIAÇÃO DE SATISFAÇÃO (index do cliente)
// ══════════════════════════════════════════════════════════════
var _avalDados = { agendamento_id: null, nota: 0 };
const _AVAL_LABELS = { 1:'😞 Péssimo', 2:'😕 Ruim', 3:'😐 Regular', 4:'😊 Bom', 5:'🤩 Excelente!' };
function abrirModalAvaliacao(agendamento) {
// agendamento = objeto do agendamentosCliente
_avalDados = {
agendamento_id: agendamento.id || null,
cliente_nome: clienteNome || agendamento.cliente_nome || '',
cliente_email: clienteEmail || '',
barbeiro_nome: agendamento.barbeiro || agendamento.barbeiro_nome || '',
servico: agendamento.servico || '',
data_servico: agendamento.data || '',
nota: 0
};
const det = document.getElementById('aval-detalhe');
if (det) det.textContent = (agendamento.servico || '') + (agendamento.barbeiro ? ' com ' + agendamento.barbeiro : '') + (agendamento.data ? ' — ' + agendamento.data : '');
document.getElementById('aval-comentario').value = '';
document.getElementById('aval-btn-enviar').disabled = true;
document.getElementById('aval-label').textContent = '';
_avalRenderEstrelas(0);
var m = document.getElementById('avaliacaoModal');
m.style.display = 'flex';
}
function fecharModalAvaliacao() {
document.getElementById('avaliacaoModal').style.display = 'none';
}
function avalSetNota(n) {
_avalDados.nota = n;
_avalRenderEstrelas(n);
document.getElementById('aval-label').textContent = _AVAL_LABELS[n] || '';
document.getElementById('aval-btn-enviar').disabled = false;
}
function _avalRenderEstrelas(nota) {
document.querySelectorAll('.aval-star').forEach(function(btn) {
var v = Number(btn.getAttribute('data-v'));
btn.style.opacity = v <= nota ? '1' : '0.25';
btn.style.transform = v <= nota ? 'scale(1.15)' : 'scale(1)';
});
}
function enviarAvaliacao() {
if (!_avalDados.nota) return;
var comentario = (document.getElementById('aval-comentario').value || '').trim();
var btn = document.getElementById('aval-btn-enviar');
btn.disabled = true;
btn.textContent = 'Enviando...';
gsr.call('salvarAvaliacao', [Object.assign({}, _avalDados, { comentario: comentario })])
.then(function() {
fecharModalAvaliacao();
// Marcar como avaliado no localStorage para não perguntar de novo
try { localStorage.setItem('aval_' + _avalDados.agendamento_id, '1'); } catch(e) {}
SW('Obrigado! 🙏', 'Sua avaliação foi registrada com sucesso.', 'success');
if (typeof carregarAgenCli === 'function') carregarAgenCli();
})
.catch(function(e) {
btn.disabled = false;
btn.textContent = 'Enviar Avaliação';
SW('Erro', e?.message || 'Falha ao enviar avaliação.', 'error');
});
}
// Verifica se há agendamentos concluídos sem avaliação e abre o modal
function _verificarAvaliacoesPendentes() {
if (!clienteEmail || !agendamentosCliente.length) return;
const concluidos = agendamentosCliente.filter(function(a) {
const st = (a.status || '').toLowerCase().replace('í','i');
return (st === 'concluido' || st.replace('í','i') === 'concluido') && a.id;
});
if (!concluidos.length) return;
// Verifica localmente quais já foram avaliados
const pendente = concluidos.find(function(a) {
try { return !localStorage.getItem('aval_' + a.id); } catch(e) { return false; }
});
if (!pendente) return;
// Verifica no servidor (evita duplicata)
gsr.call('verificarAvaliacao', [pendente.id])
.then(function(r) {
if (!r.avaliado) {
// Pequeno delay para não sobrepor outros modais
setTimeout(function() { abrirModalAvaliacao(pendente); }, 800);
} else {
try { localStorage.setItem('aval_' + pendente.id, '1'); } catch(e) {}
}
})
.catch(function() {});
}
// ══════════════════════════════════════════════════════════════
// AVALIAÇÃO NO RODAPÉ (pública, sem login)
// ══════════════════════════════════════════════════════════════
var _footerAvalNota = 0;
var _FOOTER_AVAL_LABELS = {1:'😞 Péssimo',2:'😕 Ruim',3:'😐 Regular',4:'😊 Bom',5:'🤩 Excelente!'};
function footerAvalNota(n) {
_footerAvalNota = n;
// Renderiza estrelas
document.querySelectorAll('#footer-aval-stars button').forEach(function(btn) {
var v = Number(btn.getAttribute('data-v'));
btn.style.opacity = v <= n ? '1' : '0.2';
btn.style.transform = v <= n ? 'scale(1.2)' : 'scale(1)';
});
document.getElementById('footer-aval-label').textContent = _FOOTER_AVAL_LABELS[n] || '';
// Mostra campo de nome só se não estiver logado
var nomeWrap = document.getElementById('footer-aval-nome-wrap');
if (nomeWrap) nomeWrap.style.display = clienteNome ? 'none' : 'block';
// Mostra área de comentário + botão enviar
document.getElementById('footer-aval-extra').style.display = 'block';
}
function footerAvalEnviar() {
if (!_footerAvalNota) return;
var btn = document.getElementById('footer-aval-btn');
var comentario = (document.getElementById('footer-aval-coment').value || '').trim();
var nomeDigitado = (document.getElementById('footer-aval-nome')?.value || '').trim();
var nome = clienteNome || nomeDigitado || '';
btn.disabled = true;
btn.textContent = 'Enviando...';
gsr.call('salvarAvaliacao', [{
nota: _footerAvalNota,
comentario: comentario,
cliente_nome: nome,
cliente_email: clienteEmail || '',
barbeiro_nome: '',
servico: '',
data_servico: ''
}])
.then(function() {
document.getElementById('footer-aval-form').style.display = 'none';
document.getElementById('footer-aval-ok').style.display = 'block';
// Guarda no localStorage para não pedir de novo tão cedo (24h)
try { localStorage.setItem('footer_aval_ts', Date.now()); } catch(e) {}
})
.catch(function() {
btn.disabled = false;
btn.textContent = 'Enviar Avaliação';
alert('Erro ao enviar. Tente novamente.');
});
}
// Esconde o bloco se já avaliou nas últimas 24h (não irritar o usuário)
(function() {
try {
var ts = Number(localStorage.getItem('footer_aval_ts') || 0);
if (ts && (Date.now() - ts) < 86400000) {
var wrap = document.getElementById('footer-aval-wrap');
if (wrap) wrap.style.display = 'none';
}
} catch(e) {}
})();
// ══════════════════════════════════════════════════════════════
// AVALIAÇÕES PÚBLICAS — exibição no rodapé
// ══════════════════════════════════════════════════════════════
// DEPOIS:
var _reviewsTodas = [];
var _reviewsVisiveis = 3; // quantas mostrar inicialmente
function _carregarReviewsPublicas() {
gsr.call('listarAvaliacoes', [{ limite: 50 }])
.then(function(res) {
_reviewsTodas = res.lista || [];
var stats = res.stats || {};
var loading = document.getElementById('footer-reviews-loading');
var media = document.getElementById('footer-reviews-media');
if (loading) loading.style.display = 'none';
if (!_reviewsTodas.length) {
var cont = document.getElementById('footer-reviews-lista');
if (cont) cont.innerHTML = '
Nenhuma avaliação ainda. Seja o primeiro! 😊
';
return;
}
if (media && stats.total) {
var estrelasFill = Math.round(Number(stats.media));
media.innerHTML = '★'.repeat(estrelasFill) + '☆'.repeat(5 - estrelasFill)
+ ' ' + stats.media + ''
+ ' (' + stats.total + ' avaliações)';
media.style.color = '#eab308';
}
_reviewsRenderizar();
})
.catch(function() {
var loading = document.getElementById('footer-reviews-loading');
if (loading) loading.style.display = 'none';
});
}
function _reviewsRenderizar() {
var cont = document.getElementById('footer-reviews-lista');
var btnWrap = document.getElementById('footer-reviews-ver-mais');
if (!cont) return;
var notaCor = {1:'#ef4444',2:'#f97316',3:'#eab308',4:'#84cc16',5:'#22c55e'};
var visiveis = _reviewsTodas.slice(0, _reviewsVisiveis);
cont.innerHTML = visiveis.map(function(av) {
var estrelas = '★'.repeat(av.nota) + '★'.repeat(5 - av.nota);
var nome = av.cliente_nome ? av.cliente_nome.split(' ')[0] : 'Cliente';
var data = av.criado_em ? av.criado_em.slice(0,10).split('-').reverse().join('/') : '';
var detalhe = [av.servico, av.barbeiro_nome].filter(Boolean).join(' · ');
return '
'
+ '
'
+ '
'
+ '
' + estrelas + '
'
+ '
' + nome + '
'
+ (detalhe ? '
✂️ ' + detalhe + '
' : '')
+ '
'
+ '
' + data + '
'
+ '
'
+ (av.comentario ? '
"' + av.comentario + '"
' : '')
+ '
';
}).join('');
// Mostra/oculta botão "Ver mais"
if (btnWrap) btnWrap.style.display = _reviewsTodas.length > _reviewsVisiveis ? 'block' : 'none';
}
function _reviewsExpandir() {
_reviewsVisiveis += 5; // mostra mais 5 por clique
_reviewsRenderizar();
}
// Carrega ao iniciar a página
(function() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _carregarReviewsPublicas);
} else {
setTimeout(_carregarReviewsPublicas, 800);
}
})();
// Recarrega após nova avaliação enviada pelo footer
var _origFooterAvalEnviar = footerAvalEnviar;
footerAvalEnviar = function() {
_origFooterAvalEnviar();
setTimeout(_carregarReviewsPublicas, 1500);
};
// ══════════════════════════════════════════════════════════════
// RESTAURAR SESSÃO
// ══════════════════════════════════════════════════════════════
// [SUBSTITUÍDO] api.run → gsr.call
// CORREÇÃO: não setar clienteEmail antes do servidor validar a sessão.
// Antes: clienteEmail era setado sincronamente — se alguém estava logado
// no mesmo browser, o próximo cliente agendava com o email errado.
(function restaurarSessao(){
const email=sessaoLer();
if(!email)return;
// Não setar clienteEmail aqui — aguarda confirmação do servidor
gsr.call('sessaoValida', [email])
.then(ok => {
if(ok){ clienteEmail=email; entrarAreaCli(email); }
else{ sessaoLimpar(); }
})
.catch(() => { sessaoLimpar(); });
})();
// ══════════════════════════════════════════════════════════════
// BOTÃO WHATSAPP
// ══════════════════════════════════════════════════════════════
function mostrarWhatsAppFab() {
const fab = document.getElementById('whatsappFab');
if (fab) {
fab.classList.add('visible');
try { localStorage.setItem('whatsapp_fab_visible_' + _slug, 'true'); } catch(e) {}
}
}
function ocultarWhatsAppFab() {
const fab = document.getElementById('whatsappFab');
if (fab) {
fab.classList.remove('visible');
try { localStorage.removeItem('whatsapp_fab_visible_' + _slug); } catch(e) {}
}
}
(function restaurarWhatsAppFab() {
try {
const visivel = localStorage.getItem('whatsapp_fab_visible_' + _slug);
if (visivel === 'true') {
setTimeout(() => mostrarWhatsAppFab(), 500);
}
} catch(e) {}
})();
(function() {
try {
aplicarTema(localStorage.getItem(_TEMA_KEY_LOJA) || 'padrao');
} catch(e) {}
})();
_aplicarVisualInstantaneo();
carregarTudoIniciais();
// Evita bfcache: recarrega a página se ela foi restaurada do cache do browser
window.addEventListener('pageshow', function(e) {
if (e.persisted) { window.location.reload(); }
});
// ══════════════════════════════════════════════════════════════════
// BARBERVIP+ — Sistema de Fidelidade por Atendimento
// ══════════════════════════════════════════════════════════════════
let _bcCfg = { ativo: false, meta: 10, premio: '', pontosAtendimento: true, pontosIndicacao: 0, pontosProduto: 0, modo: 'cartela' };
let _bcClientesTodos = [];
// ── Carregamento principal ────────────────────────────────────────
function bcCarregar() {
const nomeBarbeiro = (typeof usuarioNome !== 'undefined' && usuarioNome) || usuarioEmail || '';
if (!nomeBarbeiro) return;
const lista = document.getElementById('bc-clientes-lista');
if (lista) lista.innerHTML = '